有非常多的套件或是框架,可以輔助我們寫出高品質的測試。如何有效地善用這些工具,最好的方法就是去理解工具背後是怎麼運作的。而理解工具背後的運作原理,最好的方式之一就是自己動手實作一個。
現在就來實作一個超級簡易版的測試工具吧!
首先建立一個 calculation.js
檔案,裡面有一個叫做 sum
的 function 幫我們計算加法,但其實這個 function 有個 bug:運算符寫成 -
號了。
calculation.js
const sum = (a, b) => a - b // 有 bug
這時候我們要來簡單寫一段程式碼,讓它自動幫我們檢查,如果 sum
執行的結果跟預期的正確結果不一樣的時候,就拋出一個錯誤:
const result = sum(8, 7)
const expected = 15
if (result !== expected) {
throw new Error(`${result} is not equal to ${expected}`)
}
接下來執行這個檔案:
$ node calculation.js
/Users/shane/Desktop/calculation.js:7
throw new Error(`${result} is not equal to ${expected}`)
^
Error: 1 is not equal to 15
at Object.<anonymous> (/Users/shane/Desktop/calculation.js:7:9)
at Module._compile (internal/modules/cjs/loader.js:1201:30)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1221:10)
at Module.load (internal/modules/cjs/loader.js:1050:32)
at Function.Module._load (internal/modules/cjs/loader.js:938:14)
at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:71:12)
at internal/main/run_main_module.js:17:47
果然拋出錯誤。恭喜你,已經大功告成了! (欸)
當然還沒啦,不過,目前已經可以體會大部分的測試框架,基本上跟我們剛剛所做的事情目的差不多:如果有非預期的結果的話,就拋出錯誤訊息,目的是讓開發者可以快速辨識問題並解決它。
回到剛剛的 sum
,我們將 -
號改正為 +
號,再執行一次 node calculation.js
,正常來說,已經不會看到任何錯誤訊息了。
可以再試著加入一個叫 subtract
****的 function 到 calculation.js
中:
const subtract = (a, b) => a - b
照著剛剛的邏輯也為它加上測試,調整一下程式碼,變成這樣:
const sum = (a, b) => a + b
const subtract = (a, b) => a - b
let result = sum(8, 7)
let expected = 15
if (result !== expected) {
throw new Error(`${result} is not equal to ${expected}`)
}
result = subtract(8, 7)
expected = 1
if (result !== expected) {
throw new Error(`${result} is not equal to ${expected}`)
}
執行 node calculation.js
,正常的話,不會拋出任何錯誤。
現在,我們可以把測試邏輯給抽出來,新建一個 assertion.js
檔案,引入剛剛的 sum
跟 subtract
:
calculation.js
const sum = (a, b) => a - b // 有 bug
const subtract = (a, b) => a - b
module.exports = {
sum,
subtract,
}
asserion.js
const { sum, subtract } = require('./calculation')
let result, expected
result = sum(8, 7)
expected = 15
if (result !== expected) {
throw new Error(`${result} is not equal to ${expected}`)
}
result = subtract(8, 7)
expected = 1
if (result !== expected) {
throw new Error(`${result} is not equal to ${expected}`)
}
我們可以進一步抽出重複的邏輯,將他們收斂在一個 function 中,這個 function 會回傳一個包含測試方法的 object:
function expect(result) {
return {
toBe(expected) {
}
}
}
再把剛剛驗證結果是否相等的那段程式碼搬進去:
function expect(result) {
return {
toBe(expected) {
if (result !== expected) {
throw new Error(`${result} is not equal to ${expected}`)
}
},
}
}
現在可以這樣來寫測試,把剛剛重複的測試邏輯,變得更直覺易讀:
const result = sum(8, 7)
const expected = 15
expect(result).toBe(expected) // 變成易讀的 assertion
這個步驟所建立的 expect
function 正是模擬一個 assertion 套件在做的事情:接收一個值,回傳一個包含各種不同 assertion 的 object,來驗證預期的結果。
到目前為止,我們寫的這個測試工具有個缺點:當某個測試拋出錯誤之後,就不會繼續跑後面的測試了,所以無法看到每個測試的結果。
而且錯誤訊息不清楚,我們只知道哪一行 throw 了 error、錯誤訊息是什麼,無法明確知道到底是 sum
還是 subtract
出錯。
所以,我們現在要來模擬框架在做的事:幫助開發者快速辨識確切出錯的位置,提供明確的錯誤訊息,而且確保每個欲驗證的測試都能呈現結果。
現在創建一個 framework.js
的檔案,宣告一個 test
的 function,接收第一個參數 title
作為這個測試的名稱,接收的第二個參數為 callback
這個 function,當 callback
拋出錯誤的時候,可以 catch 並印出 error:
framework.js
function test(title, callback) {
try {
callback()
} catch (error) {
console.error(error)
}
}
接著,試著加入更明確的訊息,告訴我們這些測試是成功或是失敗:
function test(title, callback) {
try {
callback()
console.log(`✅ ${title}`)
} catch (error) {
console.error(`❌ ${title}`)
console.error(error)
}
}
現在我們可以呼叫 test
,將第二步寫的測試搬進 callback
function 中,將測試改寫成這樣:
test('add numbers', () => {
const result = sum(8, 7)
const expected = 15
expect(result).toBe(expected)
})
test('subtract numbers', () => {
const result = subtract(8, 7)
const expected = 1
expect(result).toBe(expected)
})
執行 node framework.js
,現在可以更明確知道每個測試的結果了:
$ node framework.js
❌ add numbers
Error: 1 is not equal to 15
at Object.toBe (/Users/shane/Desktop/assertion.js:7:15)
at /Users/shane/Desktop/framework.js:17:18
at test (/Users/shane/Desktop/framework.js:6:5)
at Object.<anonymous> (/Users/shane/Desktop/framework.js:14:1)
at Module._compile (internal/modules/cjs/loader.js:1201:30)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1221:10)
at Module.load (internal/modules/cjs/loader.js:1050:32)
at Function.Module._load (internal/modules/cjs/loader.js:938:14)
at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:71:12)
at internal/main/run_main_module.js:17:47
✅ subtract numbers
前面都是同步的情況,那如果我們要測試非同步的結果呢?
你可能會很直覺地想說:把傳進去的 callback function 改成 async function 就好了吧:
test('add numbers', async () => {
const result = await sumAsync(8, 7)
const expected = 15
expect(result).toBe(expected)
})
test('subtract numbers', async () => {
const result = await subtractAsync(8, 7)
const expected = 1
expect(result).toBe(expected)
})
乍看之下好像沒什麼問題,但執行一下發現:
$ node async-await.js
✅ add numbers
✅ subtract numbers
(node:7053) UnhandledPromiseRejectionWarning: Error: 1 is not equal to 15
at Object.toBe (/Users/shane/Desktop/assertion.js:7:15)
at /Users/shane/Desktop/async-await.js:17:18
(Use `node --trace-warnings ...` to show where the warning was created)
(node:7053) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
(node:7053) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
事情不是你想的那樣,應該報錯的測試居然通過了,這未免太不尋常。還看到一個 UnhandledPromiseRejectionWarning
的警告,這個警告就是在提醒我們有漏接的錯誤,正是那個應該報錯的測試拋出來的。
因為 callback functon 改成 async function 的形式了,它會回傳一個 promise。如果我們沒有等待 promise 的結果,就會造成剛剛的情況: test
function 永遠都會先執行 try block 裡面的 console.log
,等到 promise 的狀態轉為 rejected 時,拋出的錯誤就被漏接了。
所以,現在要做的是:將 test
function 也改為 async function, await
裡面的 callback
:
async function test(title, callback) {
try {
await callback()
console.log(`✅ ${title}`)
} catch (error) {
console.error(`❌ ${title}`)
console.error(error)
}
}
執行之後,測試就是我們預期的結果了。
現在的 test
function 可以同時套用在同步與非同步的測試。
我們目前可以將 test
跟 expect
放進 module 裡,在測試檔案裡引入。但這樣需要在每個測試檔案裡 require
module,有點不方便。我們可以嘗試像大部分的套件一樣,不需要在測試檔案裡引入 module,直接執行檔案。
創建一個 global-framework.js
檔案,把test
跟 expect
放進去:
async function test(title, callback) {
try {
await callback();
console.log(`✅ ${title}`);
} catch (error) {
console.error(`❌ ${title}`);
console.error(error);
}
}
function expect(result) {
return {
toBe(expected) {
if (result !== expected) {
throw new Error(`${result} is not equal to ${expected}`);
}
},
};
}
一樣在 global-framework.js
裡加上 global method:
global.test = test;
global.expect = expect;
另外,建立一個 test-global.js
的檔案,裡面有我們想進行的測試,不用另外引入 test
跟 expect
module:
const { sum, subtract, sumAsync, subtractAsync } = require('./calculation')
test('add numbers', () => {
const result = sum(8, 7);
const expected = 15;
expect(result).toBe(expected);
});
test('subtract numbers', () => {
const result = subtract(8, 7);
const expected = 1;
expect(result).toBe(expected);
});
test('add numbers async', async () => {
const result = await sumAsync(8, 7);
const expected = 15;
expect(result).toBe(expected);
});
test('subtract numbers async', async () => {
const result = await subtractAsync(8, 7);
const expected = 1;
expect(result).toBe(expected);
});
現在可以透過下面這樣來測試 test-global.js
:
node --require ./global-framework.js test-global.js
node
後面加上--require
是指在執行之前預先載入特定 module,這裡指的是載入global-framework.js
到這個步驟,我們大致完成了這個超級簡易的測試工具。(拍手
前面寫的這個超簡易測試工具,其實跟 Jest 這個測試框架有 87% 像。當然不是像 Jest 那麼強大,而是我們模擬的功能,就是在體會 Jest 要幫我們達成的目的。
Jest 預設會執行所有 test.js
結尾的檔案 ,所以我們新增一下 jest.test.js
檔案,把剛剛 test-global.js
的程式碼搬進來,現在直接用 Jest 來跑測試看看:
npx jest
路徑下需要有
jest.config.js
才不會報錯
會發現,Jest 有非常清晰的資訊,甚至還有程式碼框框,一眼就能看出錯誤在哪一行,這就是測試框架很強大的細節之一。
FAIL ./jest.test.js
× add numbers (7 ms)
√ subtract numbers
× add numbers async (12 ms)
√ subtract numbers async (11 ms)
● add numbers
expect(received).toBe(expected) // Object.is equality
Expected: 15
Received: 1
4 | const result = sum(8, 7);
5 | const expected = 15;
> 6 | expect(result).toBe(expected);
| ^
7 | });
8 | test('subtract numbers', () => {
9 | const result = subtract(8, 7);
at Object.<anonymous> (jest.test.js:6:20)
● add numbers async
expect(received).toBe(expected) // Object.is equality
Expected: 15
Received: 1
14 | const result = await sumAsync(8, 7);
15 | const expected = 15;
> 16 | expect(result).toBe(expected);
| ^
17 | });
18 | test('subtract numbers async', async () => {
19 | const result = await subtractAsync(8, 7);
at Object.<anonymous> (jest.test.js:16:20)
Test Suites: 1 failed, 1 total
Tests: 2 failed, 2 passed, 4 total
Snapshots: 0 total
Time: 8.309 s
Ran all test suites.
經過這篇文章自己動手實作後,可以深刻體會使用測試框架是要幫助我們達成什麼: